fix(audit): per-invocation attribution for egress-proxy + MCP consent events (#341, #366) - #367
Conversation
Egress-proxy audit events (source=proxy) carried task_id/correlation_id (#338) but no seq, so they were invisible to gap-detection (which walks seq within a (correlation_id, task_id) group). Root cause: the proxy is a separate 127.0.0.1 forwarder with no request ctx, so it emits via plain Emit and skips the ctx-scoped counter. Add coreruntime.SequenceRegistry — a per-invocation counter registry keyed by (correlation_id, task_id). The runner registers each invocation's counter at request entry (all three A2A entry points) and evicts at the invocation boundary; the proxy OnAttempt recovers (corr, task) from the replayed Proxy-Authorization creds and advances the SAME counter via NextSequenceFor, so proxy events join the gap-free seq chain. A miss returns 0 (seq-less, never a wrong/duplicate seq) — startup + unattributed events are unaffected. This registry is also the seam #366 uses to attribute MCP consent-resume egress. Tests: registry Register/Get/Evict/NextSequenceFor + shared-counter + race + nil-safe; runner registerInvocationSeq shares the in-context counter and evicts. Build/vet/lint clean.
…366) Two attribution defects on the MCP delegated-consent path: A. mcp_auth_resolved was double-emitted, and 2 of 3 sites dropped attribution. The waking gate (mcpAuthGate.Await) already emits an attributed mcp_auth_resolved (grant) / mcp_auth_timeout (refusal). The resume channels emitted a second, UNATTRIBUTED copy via plain Emit on a different request: - POST /mcp/consent: dropped entirely — Peek guarantees a waiter, so the gate covers it; a resolved event for a refusal (decision=timeout) was misleading. - loopback callback: now emits ONLY when Resolve finds no waiter (a late grant after the original call timed out — genuinely unattributed, no invocation). B. The loopback callback's code->token exchange ran on the browser request ctx, so its egress surfaced unattributed. The parked invocation is still in flight (its seq counter is still registered #341), so: add CorrelationID to authgate.Spec (travels with the first waiter), recover (correlation_id, task_id) via engine.Peek at the callback, and seed the completion ctx (WithCorrelationID/WithTaskID + the registered seq counter). The token exchange (ExchangeCodeCtx) now attributes + seq's to the parked invocation. Not covered here: the pooled MCP connection establishment (subject_pool.go context.Background()) — a connection is shared across invocations, so that egress is infrastructure (like startup), not cleanly per-invocation; documented as a follow-up boundary. Tests: callback seeds the parked (corr,task)+shared counter; authgate + consent suites green.
…le + attributed (#366) audit-logging.md: update the plain-Emit exception for source=proxy — proxy events now carry a correct seq via the SequenceRegistry (only trace cross-link remains unavailable). delegated-consent.md: mcp_auth_resolved/timeout emitted once by the resuming gate with the invocation's correlation/task/seq; the late-grant no-waiter case is the only unattributed exception.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: per-invocation attribution for egress-proxy + MCP consent (#341, #366)
Clean, well-reasoned PR. Concurrency is correct, leak-prevention is sound, and the MCP attribution stays strictly on the observability side of the security boundary — no cross-user misattribution. No blocking or security findings. Merge-ready. CI fully green.
SequenceRegistry — race- and leak-safe ✅
- Concurrency: map is mutex-guarded;
NextSequenceFordoesAdd(1)on the live*atomiccounter, so out-of-band and in-context emitters advance the same counter — no lost increments or duplicate seqs. Eviction racing an in-flightAddis safe (the counter object outlives the map entry via the returned pointer; no panic, no race — the-racesuite backs this). - Leak-safe:
defer r.registerInvocationSeq(ctx)()registers immediately, evicts on return including panic unwinding. Nil/empty-key guards prevent phantom entries. - Correct sentinel: real seqs start at 1 (
Add(1)on zero), miss returns 0 → JSON-omitted, so "unregistered" and a real seq never collide. - Key safety:
corr + "\x00" + taskrules out the("a","bc")vs("ab","c")ambiguity.
#341 wiring — the key match holds ✅
Verified the thing that would silently defeat this: at all three A2A entry points (JSON-RPC, executeTask, REST), EnsureCorrelationID + WithTaskID + EnsureSequenceCounter run before registerInvocationSeq, so the registration key is the real (corr, task) the subprocess later replays via Proxy-Authorization (#338). The proxy's NextSequenceFor hits the right counter; proxy events join the gap-free chain.
#366 MCP consent — attribution binding is secure ✅
- No cross-user misattribution: the callback recovers
(corr, task)fromengine.Peek(b.subject, b.server)whereb.subjectis the state-bound subject (PKCE/state/session validation unchanged). The gate is per-(subject, server), so the exchange attributes to the invocation that parked on that subject's gate. Attributing to the first same-subject waiter (when several exist) is deterministic and same-subject. - Observability-only: the seeded ctx changes only correlation/task/seq — it does not touch what token is minted, for whom, or the resume decision, so it can't cause a wrong token to be exchanged/stored. Good separation across the security boundary.
- Double-emit removal loses no event:
POST /mcp/consentdrops its emit becausePeekguarantees a waiter and the waking gate emits the attributed terminal event (and aresolvedfor atimeoutrefusal was genuinely misleading). The loopback callback now emits only onResolve-finds-no-waiter — the genuine "late grant after timeout" case, markedlate: trueand honestly unattributed (no live invocation). - The scope boundary (pooled
subject_pool.goconnection = shared-across-invocations infra, deferred rather than forced onto one arbitrary invocation) is the right judgment and honestly documented.
Two things I considered and dismissed (recording so they don't resurface)
- Nested register/evict: not a concern —
tasks/send(JSON-RPC + REST) delegates toexecuteTask, which registers internally, while theregisterInvocationSeqdefers in the streaming (tasks/sendSubscribe) handlers cover the inline-executing streaming path. A request is either non-streaming or streaming, so the two paths are mutually exclusive — no double-registration, no early-evict window. - Single registry mutex: taken per proxy egress event + per request entry/exit, but O(1) map ops under a brief lock — fine at realistic audit volume; sharding/
sync.Mapwould be premature.
Tests cover the registry (Register/Get/Evict/NextSequenceFor, shared-counter, -race, nil-safe), the runner sharing+evicting, and the callback seeding the parked (corr, task) + counter.
Nice design — the SequenceRegistry seam unifying both defects is clean, and keeping attribution observability-only leaves the delegated-consent security model untouched.
Fixes #341 and #366 together — both are "events emitted outside the request goroutine lose per-invocation attribution." A shared
SequenceRegistryis the unifying seam.Shared foundation —
coreruntime.SequenceRegistryA registry mapping each invocation's live
*atomic.Int64sequence counter to its(correlation_id, task_id). The runner registers the counter at every A2A request-entry point and evicts it at the invocation boundary. Out-of-band emitters look the counter up and advance the same counter the in-context path uses — one gap-free sequence. A miss returns 0 (seq-less, never a wrong/duplicate seq).#341 — egress-proxy events now carry
seqSubprocess egress-proxy events (
source=proxy) carriedtask_id/correlation_id(#338) but noseq, so they were invisible to gap-detection (which walksseqwithin a(correlation_id, task_id)group). Root cause: the proxy is a separate 127.0.0.1 forwarder with no request ctx.Fix: the proxy
OnAttemptrecovers(corr, task)from the replayedProxy-Authorizationcreds (#338) and callsSequenceRegistry.NextSequenceFor→ proxy events join the gap-free chain. Trace cross-link still needs the ctx span, so it stays unavailable (documented).#366 — MCP consent audit + egress attribution
A.
mcp_auth_resolvedwas double-emitted, 2 of 3 sites unattributed. The waking gate (mcpAuthGate.Await) already emits an attributedmcp_auth_resolved(grant) /mcp_auth_timeout(refusal). The resume channels emitted a second, unattributed copy on a different request:POST /mcp/consent: dropped entirely (Peek guarantees a waiter → the gate covers it; aresolvedevent for a refusal was also misleading).Resolvefinds no waiter (a late grant after timeout — genuinely unattributed, no invocation).B. The loopback callback's code→token exchange ran on the browser request ctx → unattributed egress. The parked invocation is still in flight (its counter is still registered), so: add
CorrelationIDtoauthgate.Spec(travels with the first waiter), recover(corr, task)viaengine.Peekat the callback, and seed the completion ctx (WithCorrelationID/WithTaskID+ the registered counter). The token exchange now attributes + seq's to the parked invocation.Scope boundary (documented, not fixed here)
The pooled MCP connection establishment (
subject_pool.go,context.Background()) surfaces some resume-timemcp.atlassian.comegress unattributed. A connection is shared across invocations, so that egress is infrastructure (like startup) rather than cleanly per-invocation — noted as a follow-up rather than forced onto one arbitrary invocation.Tests
-race, nil-safe.registerInvocationSeqshares the in-context counter + evicts.(corr, task)+ shared counter; authgate + consent + mcp + runtime suites green.gofmt+go vet+golangci-lintclean across forge-core + forge-cli; doc-links resolve.